Skip to content

fix(wrapper): stop Wrap mutating the ingress it is given - #84

Merged
thorwhalen merged 1 commit into
masterfrom
claude/fix-wrap-ingress-mutation
Aug 5, 2026
Merged

fix(wrapper): stop Wrap mutating the ingress it is given#84
thorwhalen merged 1 commit into
masterfrom
claude/fix-wrap-ingress-mutation

Conversation

@thorwhalen

Copy link
Copy Markdown
Member

Found while reviewing the abandoned claude/implement-wrapper-improvements-* branch (thorwhalen/priv#38). That branch turned out to be already merged by another route — its only remaining difference from master is quote style. But reviewing it surfaced a real bug in the code that did land.

The bug

preserve_signature wrote __signature__ and __annotations__ onto the ingress object. A decorator normally defines one ingress and reuses it for every function it wraps, so that write corrupts the caller's function — and every later Wrap built from the same ingress:

def shared_ingress(*args, **kwargs): return args, kwargs
def f(a: int) -> int: ...
def g(x: str, y: str) -> str: ...

Wrap(f, ingress=shared_ingress)
signature(shared_ingress)             # (a: int) -> int      <- caller's function MUTATED
Sig(Wrap(g, ingress=shared_ingress))  # (a: int) -> str      <- WRONG

Wrap(g, ...) still executes correctly (w2('a','b') == 'ab'), so nothing ever surfaced the lie. That is the worst shape this bug could take in this package: silently wrong in exactly the introspection i2 exists to provide, and meshed builds its DAGs from these signatures.

The fix

Preserving means the wrapper presents func's interface — so read the signature from func, and never write it onto the ingress. Defaults are now read from the same source as the signature, so the two can't disagree.

Cleanups in the same area

  • _get_return_annotation had x if x is not Parameter.empty else empty three times. empty is Parameter.empty, so each was a no-op around a duplicated fallback. Now one rule — egress wins if annotated, else func — with doctests covering the case that made the dead branch look meaningful: an unannotated func yields empty, not None, since None is a real annotation meaning "returns None".
  • Extracted _is_generic_signature (the predicate 'auto' actually turns on), with doctests for the near-misses: (*a) alone, (x, *a, **kw).
  • Dropped the unused func parameter from _should_preserve_signature.
  • Named the 'auto' sentinel AUTO_PRESERVE_SIGNATURE so it has one definition.
  • Converted the numpydoc docstrings to the :param: style used elsewhere in this module.

Public API unchanged.

Verification

  • The three regression tests fail on the old code and pass on the new — verified by stashing the fix.
  • i2: 470 passed, 2 xfailed.
  • meshed (biggest dependent): 60 passed.
  • Swept every local package importing i2.wrapper: extrude, front, larder, oa pass; crude and o fail identically with and without this change (selenium, missing hum.sound) — pre-existing.
  • Zero new ruff findings (732 before and after).

Two hunks in the diff are ruff reformatting untouched assert statements — the publish job runs ruff format . and pushes back anyway, so they'd land regardless.

https://claude.ai/code/session_01GsUw8ey8KikzNFQ1UaWWia

`preserve_signature` wrote `__signature__`/`__annotations__` onto the ingress object.
A decorator normally defines one ingress and reuses it for every function it wraps, so
that write corrupted the caller's function and made every later Wrap built from the same
ingress advertise the FIRST wrapped function's signature -- while still executing
correctly, so nothing surfaced the lie:

    def shared_ingress(*args, **kwargs): return args, kwargs
    def f(a: int) -> int: ...
    def g(x: str, y: str) -> str: ...

    Wrap(f, ingress=shared_ingress)
    signature(shared_ingress)          # (a: int) -> int   <- caller's function mutated
    Sig(Wrap(g, ingress=shared_ingress))  # (a: int) -> str <- wrong, but g still works

That is the worst shape of bug for this package: silent, and wrong in exactly the
introspection i2 exists to provide (meshed builds DAGs from these signatures).

Preserving means *the wrapper* presents func's interface, so read the signature from
func and never write it onto the ingress. Defaults now come from the same source as the
signature, so the two cannot disagree.

Also, while in here:

- `_get_return_annotation` had `x if x is not Parameter.empty else empty` three times.
  `empty IS Parameter.empty`, so each was a no-op wrapped around a duplicated fallback.
  Reduced to one "egress wins if annotated, else func" rule with doctests, including the
  case that made the dead branch look meaningful (an unannotated func gives `empty`,
  not `None` -- `None` is a real annotation meaning "returns None").
- Extracted `_is_generic_signature`, the actual predicate `'auto'` turns on, with
  doctests for the near-misses ((*a) alone, (x, *a, **kw)).
- Dropped the unused `func` parameter from `_should_preserve_signature`.
- Named the `'auto'` sentinel `AUTO_PRESERVE_SIGNATURE` so it has one definition.
- Converted the numpydoc docstrings to the `:param:` style used everywhere else here.

Public API unchanged. Regression tests fail on the old code and pass on the new.
i2: 470 passed. meshed (biggest dependent): 60 passed. crude/o failures are pre-existing
and byte-identical with and without this change.

Claude-Session: https://claude.ai/code/session_01GsUw8ey8KikzNFQ1UaWWia
@thorwhalen
thorwhalen merged commit 320c021 into master Aug 5, 2026
8 checks passed
@thorwhalen
thorwhalen deleted the claude/fix-wrap-ingress-mutation branch August 5, 2026 09:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant